Skip to content

Python: Fix reasoning content parsing in OpenAIChatCompletionClient#7028

Draft
giles17 wants to merge 6 commits into
microsoft:mainfrom
giles17:fix/chat-completion-reasoning-parsing
Draft

Python: Fix reasoning content parsing in OpenAIChatCompletionClient#7028
giles17 wants to merge 6 commits into
microsoft:mainfrom
giles17:fix/chat-completion-reasoning-parsing

Conversation

@giles17

@giles17 giles17 commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Motivation and Context

Fixes #6978 and #6979.

Two related bugs in OpenAIChatCompletionClient prevent reasoning content from being displayed correctly:

  1. Python: [Bug]: OpenAI Chat Completions client buries plaintext reasoning_details as encrypted data #6979 - Plaintext reasoning buried as encrypted data: The client dumps the entire reasoning_details array into Content.protected_data without setting Content.text. Downstream, AG-UI emits ReasoningEncryptedValueEvent (opaque) instead of visible ReasoningMessageContentEvent for providers like OpenRouter that send plaintext reasoning.

  2. Python: [Bug]: OpenAI Chat Completions client crashes on (and drops) Mistral reasoning content chunks #6978 - Mistral list content causes crash: Mistral reasoning models return content as a list of typed chunks ([{"type": "thinking", ...}, {"type": "text", ...}]) instead of a plain string. _parse_text_from_openai assumed content was always a string, causing a Pydantic ValidationError crash in AG-UI.

Changes

_chat_completion_client.py

  • _extract_reasoning_text() helper (module-level): Extracts readable text from various reasoning_details formats (list of dicts with text keys, dict with content list, plain strings). Returns None for genuinely encrypted/opaque data.
  • Updated reasoning_details handling (lines 753-757, 799-803): Now passes extracted plaintext as Content.text while preserving the full JSON in protected_data for round-trip fidelity.
  • Refactored _parse_text_from_openai(): Returns list[Content] instead of Content | None. Detects when message.content is a list and delegates to _parse_chunked_content().
  • _parse_chunked_content() static method: Parses Mistral-style thinking chunks → Content.from_text_reasoning and text chunks → Content.from_text.

Tests

  • test_parse_reasoning_details_extracts_plaintext — verifies plaintext extraction from OpenRouter-style reasoning_details
  • test_parse_reasoning_details_extracts_plaintext_streaming — same for streaming path
  • test_parse_mistral_chunked_content_from_response — verifies Mistral list content parsing (non-streaming)
  • test_parse_mistral_chunked_content_streaming — verifies Mistral list content parsing (streaming)
  • test_parse_plain_string_content_still_works — regression test for normal string content

Validation

  • All 79 tests in test_openai_chat_completion_client.py pass (including 6 new tests)
  • All pre-existing reasoning round-trip tests continue to pass
  • No changes to the Responses API client (_chat_client.py) which already handles reasoning correctly

Fix two issues with reasoning content handling in the Chat Completions
client:

1. (microsoft#6979) reasoning_details plaintext buried as encrypted data:
   The client dumped the entire reasoning_details array into
   Content.protected_data without setting Content.text, causing AG-UI
   to emit ReasoningEncryptedValueEvent instead of visible
   ReasoningMessageContentEvent for plaintext reasoning providers
   (e.g. OpenRouter). Now extracts readable text from reasoning_details
   entries into Content.text while preserving protected_data for
   round-trip fidelity.

2. (microsoft#6978) Mistral list content causes crash:
   Mistral reasoning models return content as a list of typed chunks
   ([{"type": "thinking", ...}, {"type": "text", ...}]) instead of a
   plain string. _parse_text_from_openai assumed content was always a
   string, causing a Pydantic ValidationError downstream. Now detects
   list content and parses thinking chunks as Content.from_text_reasoning
   and text chunks as Content.from_text.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 9, 2026 18:42
@giles17 giles17 added the python Usage: [Issues, PRs], Target: Python label Jul 9, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated Code Review

Reviewers: 5 | Confidence: 75% | Result: All clear

Reviewed: Correctness, Security Reliability, Test Coverage, Failure Modes, Design Approach


Automated review by giles17's agents

@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Python Test Coverage

Python Test Coverage Report •
FileStmtsMissCoverMissing
packages/openai/agent_framework_openai
   _chat_completion_client.py4564091%113, 119, 138, 147–161, 163, 504, 600–601, 605, 863, 865, 870, 873, 913, 927, 1016, 1018, 1034, 1044, 1065, 1080, 1104, 1117, 1141, 1161, 1476
TOTAL44541526588% 

Python Unit Test Overview

Tests Skipped Failures Errors Time
8984 33 💤 0 ❌ 0 🔥 2m 18s ⏱️

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes parsing of “reasoning” outputs in the Python OpenAIChatCompletionClient so downstream consumers (notably AG-UI) can display plaintext reasoning correctly and avoid crashes when providers return structured chunked content.

Changes:

  • Add _extract_reasoning_text() and use it to populate Content.text for plaintext reasoning_details while preserving full payload round-tripped in protected_data.
  • Refactor _parse_text_from_openai() to return list[Content] and introduce _parse_chunked_content() to handle Mistral-style content: [...] chunks (thinking + text).
  • Add regression tests covering plaintext reasoning_details extraction and chunked list-content parsing in both streaming and non-streaming paths.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
python/packages/openai/agent_framework_openai/_chat_completion_client.py Adds plaintext reasoning extraction + chunked content parsing; updates response parsing to emit correct Content items.
python/packages/openai/tests/openai/test_openai_chat_completion_client.py Adds tests for plaintext reasoning extraction and Mistral chunked content in both streaming and non-streaming modes.

Comment thread python/packages/openai/agent_framework_openai/_chat_completion_client.py Outdated
@giles17 giles17 marked this pull request as draft July 9, 2026 18:47
Copilot and others added 2 commits July 9, 2026 18:53
- Use cast() for proper type narrowing in _extract_reasoning_text and
  _parse_chunked_content to satisfy pyright strict mode
- Handle {"content": "..."} string shape in _extract_reasoning_text
  (addresses review comment about missing format coverage)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
model_construct bypasses Pydantic runtime validation but mypy still
checks declared types. Use cast(Any, ...) for the list content args.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@giles17 giles17 marked this pull request as ready for review July 9, 2026 19:38

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated Code Review

Reviewers: 5 | Confidence: 70% | Result: All clear

Reviewed: Correctness, Security Reliability, Test Coverage, Failure Modes, Design Approach


Automated review by giles17's agents

@giles17 giles17 marked this pull request as draft July 13, 2026 18:28
Copilot and others added 3 commits July 14, 2026 16:56
- Add 'summary' field extraction in _extract_reasoning_text for
  reasoning.summary entries from OpenRouter
- Handle message.reasoning and message.reasoning_content top-level
  fields (plaintext reasoning without reasoning_details) in both
  streaming and non-streaming paths
- reasoning_details takes priority when both fields are present
- Preserve original Mistral chunk list in additional_properties
  ('_source_content_list') so _prepare_message_for_openai can
  reconstruct the structured list content for multi-turn reasoning
- Add 5 new tests covering all new behaviors

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Remove leading underscore from _skip_structured_siblings variable since
it is accessed (not a dummy variable). Ruff's used-dummy-variable rule
flags variables with leading underscores that are read.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@giles17 giles17 marked this pull request as ready for review July 14, 2026 17:53

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated Code Review

Reviewers: 5 | Confidence: 76%

✓ Correctness

The PR correctly fixes both issues (#6978 and #6979) with well-structured parsing logic and thorough tests. The _extract_reasoning_text() helper handles documented provider formats (including reasoning.summary entries), the fallback to message.reasoning/reasoning_content fields is properly gated behind an elif, and the Mistral round-trip via _source_content_list is tested end-to-end. I found no high-severity correctness issues.

✓ Security Reliability

The PR is well-structured with proper defensive type checks and graceful handling of untrusted provider response shapes. The _extract_reasoning_text helper safely handles all expected formats. The _parse_chunked_content method correctly validates chunk types before processing. The main concern is a minor reliability edge case in the skip_structured_siblings mechanism in _prepare_message_for_openai, which could silently drop unrelated text/reasoning content if it follows Mistral-style chunked content in the same Message. However, given the current parsing architecture, this situation does not arise in practice. No critical security or reliability issues found.

✓ Test Coverage

The PR adds 11 new tests covering the core happy paths for reasoning text extraction, Mistral chunked content, streaming, and round-trip serialization. The tests are well-structured with meaningful assertions. However, there is a notable gap: no test verifies that opaque/encrypted reasoning_details (entries without 'text' or 'summary' fields) correctly yield Content.text=None, which is the key invariant distinguishing genuinely encrypted data from displayable reasoning. The existing test test_parse_text_reasoning_content_from_response (line 787) tests this shape but was written before the extraction logic existed and doesn't assert the text field value — it now silently extracts 'summary' text where it previously had None.

✓ Failure Modes

The PR correctly fixes the two reported issues (reasoning plaintext extraction and Mistral list-content parsing). The skip_structured_siblings mechanism in _prepare_message_for_openai uses a type-based heuristic that can silently drop non-sibling text_reasoning content (e.g., from reasoning_details) if it follows a Mistral chunked source without intervening tool-call items. This is unlikely with current providers but represents a latent silent-failure path. No other significant failure modes were found.

✓ Design Approach

The new reasoning/chunked-content handling fixes the reported cases, but the round-trip design still has two silent data-loss edges in _prepare_message_for_openai(): it only recognizes _source_content_list when the marked content is text_reasoning, and its skip flag suppresses every later text/text_reasoning item in the same message instead of only the siblings from that structured list.


Automated review by giles17's agents


# Store the original list on the first item so it can round-trip correctly.
if results:
results[0].additional_properties["_source_content_list"] = chunks

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This round-trip marker is written onto results[0] regardless of that item's type, but the serializer only reconstructs the original structured list in the case "text_reasoning" if "_source_content_list" ... branch (lines 1068-1074). A chunk list like [{"type": "text", ...}, {"type": "thinking", ...}] is accepted by this parser, yet it will be replayed as plain assistant content instead of the original list because the marker sits on a text content and is ignored during serialization. The marker needs to be honored independently of whether the first emitted content was text or reasoning.

Comment on lines +784 to +791
# Some OpenAI-compatible providers (e.g. OpenRouter) expose plaintext reasoning
# via a top-level "reasoning" or "reasoning_content" field instead of (or in
# addition to) "reasoning_details". Surface it when no reasoning was already parsed.
elif (reasoning_str := (
getattr(choice.message, "reasoning", None)
or getattr(choice.message, "reasoning_content", None)
)) and isinstance(reasoning_str, str) and reasoning_str:
contents.append(Content.from_text_reasoning(text=reasoning_str))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is great for surfacing reasoning from things like vLLM, but a lot of open-source models also expect the reasoning to be passed back on subsequent requests. Would be nice if this could be propagated back in the request based on the key it was seen in the response. (i.e. vLLM returns reasoning and expects reasoning on associated message in the next request)

@giles17 giles17 marked this pull request as draft July 14, 2026 18:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

python Usage: [Issues, PRs], Target: Python

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Python: [Bug]: OpenAI Chat Completions client crashes on (and drops) Mistral reasoning content chunks

4 participants